50  数据框检索

50.1 引言数据检索的核心地位

数据检索(Data Retrieval)是数据分析工作流中最基础也最关键的操作之一。无论是处理几千行的实验数据,还是分析数百万条的交易记录,分析师的大部分时间都花在从数据集中提取、筛选和转换特定部分的数据上。

在金融领域,数据检索的重要性尤为突出。试想以下典型场景:

  • 时间序列分析:从十年期的日度股价数据中提取特定年份或特定市场环境下的子样本
  • 截面研究:从数千家上市公司中筛选出符合特定财务指标(如市盈率小于15、ROE大于20%)的样本
  • 事件研究:定位并购公告日前后特定窗口期内的股价波动
  • 组合构建:根据多因子筛选标准,从股票池中选出目标投资组合

这些操作本质上都是在进行数据切片(Data Slicing)和条件筛选(Conditional Filtering)。Pandas作为Python生态中最强大的数据分析工具,提供了一套完整、灵活且高效的数据检索接口。

本章学习目标:通过本章学习,你将掌握: 1. Pandas的索引系统及其设计哲学 2. 基于标签和基于位置的两套索引体系 3. 布尔索引的高级应用技巧 4. 复杂数据筛选问题的解决思路 5. 数据检索的性能优化策略

50.2 数据检索的数学基础

在深入具体操作之前,我们需要理解数据检索的数学本质。从数学角度来看,一个二维数据框(DataFrame)可以表示为一个矩阵:

\[ D = \begin{bmatrix} d_{11} & d_{12} & \cdots & d_{1m} \\ d_{21} & d_{22} & \cdots & d_{2m} \\ \vdots & \vdots & \ddots & \vdots \\ d_{n1} & d_{n2} & \cdots & d_{nm} \end{bmatrix} \in \mathbb{R}^{n \times m} \]

其中,每一行代表一个观测个体(如一家公司在某个时点的数据),每一列代表一个变量(如股价、成交量、财务指标等)。

数据检索操作可以形式化地定义为以下三种类型:

1. 行选择(Row Selection):给定行索引集合 \(I \subseteq \{1, 2, \ldots, n\}\),提取子矩阵: \[ D_{I, :} = \{d_{ij} | i \in I, j \in \{1, 2, \ldots, m\}\} \]

2. 列选择(Column Selection):给定列索引集合 \(J \subseteq \{1, 2, \ldots, m\}\),提取子矩阵: \[ D_{:, J} = \{d_{ij} | i \in \{1, 2, \ldots, n\}, j \in J\} \]

3. 条件筛选(Conditional Filtering):给定谓词函数 \(P: \mathbb{R} \to \{\text{True}, \text{False}\}\),提取满足条件的元素: \[ D_{\text{filtered}} = \{d_{ij} | P(d_{ij}) = \text{True}\} \]

理解这些数学形式有助于我们清晰地思考数据检索问题,并选择最合适的Pandas方法来实现。

50.3 Pandas的索引系统设计

Pandas提供了两套并行的索引系统,这在初学者中经常引起困惑,但实际上是精心设计的结果:

  • 基于标签的索引(loc):使用数据标签(如股票代码、日期、公司名称)进行访问,语义清晰,符合人类直觉
  • 基于位置的索引(iloc):使用整数位置进行访问,类似于传统的数组索引,在循环和算法中更方便

这种设计遵循了显式优于隐性(Explicit is better than implicit)的Python哲学,让开发者明确表达自己的意图:是在查找具有特定标签的数据,还是在查找特定位置的数据。

为什么需要两套系统?考虑以下场景:假设我们有一个以股票代码为索引的DataFrame。如果我们想获取”前3只股票”的数据,使用iloc很直接:df.iloc[0:3]。但如果我们想获取”股票代码为’600519.SH’到’600036.SH’之间”的所有股票,就必须使用loc:df.loc['600519.SH':'600036.SH']。如果只用一套索引系统,其中一个操作会变得非常复杂。

50.4 列数据的选择与访问

50.4.1 单列数据的三种访问方式

Pandas提供了三种方式来访问单列数据,每种方式适用于不同场景:

方式1:点号访问(df.column_name)

这是最简洁的访问方式,但有一个重要限制:列名必须是合法的Python标识符(不能以数字开头,不能包含空格或特殊字符)。

平台任务解答代码

以下代码与教学平台任务要求完全一致:

列表 50.1
# ⚠️ 平台原始代码 - 请原样输入至教学平台(注释除外),平台才会判定答案正确
#任务一
import pandas as pd
price_SHSC = pd.read_excel("https://huoran.oss-cn-shenzhen.aliyuncs.com/1726305586753.xlsx") #导入数据
price_SHSC["日期"] = pd.to_datetime(price_SHSC["日期"] , format='%Y%m%d')  # 转换为日期时间格式
price_SHSC.set_index("日期",inplace=True)  # 将日期列设为price_SHSC数据框的索引

print(price_SHSC.index) #查看数据框的行索引
print(price_SHSC.columns) #查看数据框的列名
print(price_SHSC.shape)  #看看数据框的形状参数

#任务二
import pandas as pd
price_SHSC = pd.read_excel("https://huoran.oss-cn-shenzhen.aliyuncs.com/1726305586753.xlsx") #导入数据

price_SHSC["日期"] = pd.to_datetime(price_SHSC["日期"] , format='%Y%m%d')  # 转换为日期时间格式
price_SHSC.set_index("日期",inplace=True)  # 将日期列设为price_SHSC数据框的索引

print(price_SHSC.loc["2024-04-08"])   #查看2024年4月8日的收盘价数据
print(price_SHSC.loc["2024-05-16"])   #查看2024年5月16日的收盘价数据
print(price_SHSC.loc["2024-06-11"])   #查看2024年6月11日的收盘价数据
print(price_SHSC.loc["2024-06-18":"2024-06-21"]) #查看2024年6月18日到2024年6月21日的数据

#任务三
import pandas as pd
price_SHSC = pd.read_excel("https://huoran.oss-cn-shenzhen.aliyuncs.com/1726305586753.xlsx") #导入数据
price_SHSC["日期"] = pd.to_datetime(price_SHSC["日期"] , format='%Y%m%d')  # 转换为日期时间格式
price_SHSC.set_index("日期",inplace=True)  # 将日期列设为price_SHSC数据框的索引
print(price_SHSC.iloc[15:28,1:4])   #截取第16行至18行、第二列至第四列

#任务四
import pandas as pd
price_SHSC = pd.read_excel("https://huoran.oss-cn-shenzhen.aliyuncs.com/20220819/xlsx/1560448223221932032.xlsx") #导入数据
price_SHSC["日期"] = pd.to_datetime(price_SHSC["日期"] , format='%Y%m%d')  # 转换为日期时间格式
price_SHSC.set_index("日期",inplace=True)  # 将日期列设为price_SHSC数据框的索引
print(price_SHSC[price_SHSC["白云机场"]>=10])  #截取白云机场收盘价大于等于17.6元对应的子数据框
# 输出华能国际
print(price_SHSC[(price_SHSC["华能国际"]<9.5)&(price_SHSC["南方航空"]>5.5)&(price_SHSC["三一重工"]<16)]) 
列表 50.2
# =============================================================================
# 题目:DataFrame列的三种访问方式
# =============================================================================
# 本任务演示如何使用三种不同的方式访问DataFrame的单列和多列数据
# 场景:从股票数据表中提取股票名称、收盘价等信息

# ==================== 导入必要的库 ====================
import pandas as pd  # Pandas数据分析库
import numpy as np  # NumPy数值计算库

# ==================== 创建示例数据:股票价格表 ====================
# 场景:4只A股的交易日数据,包含股票代码、名称、价格、涨跌幅、成交量
data = {
    '股票代码': ['600519.SH', '000858.SZ', '600036.SH', '601318.SH'],  # 上交所和深交所股票代码
    '股票名称': ['贵州茅台', '五粮液', '招商银行', '中国平安'],  # 对应的公司名称
    '收盘价': [1850.0, 220.5, 45.2, 52.8],  # 每股收盘价格(元)
    '涨跌幅': [0.05, -0.02, 0.03, -0.01],  # 日涨跌幅(小数形式,如0.05表示+5%)
    '成交量': [1200, 3500, 8900, 5600]  # 成交量(手)
}
# pd.DataFrame():将字典转换为DataFrame对象
df = pd.DataFrame(data)

# ==================== 方法1:使用点号选择单列 ====================
# df.股票名称:使用点号语法选择'股票名称'列
# 限制:列名必须是合法的Python标识符(不能以数字开头,不能有空格)
names = df.股票名称  # 返回一个Series对象
print('方法1 - 点号选择:')
print(f'类型: {type(names)}')  # type():查看对象类型,这里是pandas.Series
print(names)
# 输出解读:
# - 显示每只股票的名称
# - 返回类型是Series(一维数据结构),带索引标签
# 金融含义:
#   - Series可以理解为带索引的一维数组
#   - 非常适合表示单变量的时间序列(如某只股票的历史价格)

返回类型:单列选择返回的是Series对象,这是Pandas中的一维数据结构,可以理解为一个带有索引标签的数组。Series非常适合表示单变量的时间序列数据(如某只股票的历史价格)。

方式2:方括号访问(df[‘column_name’])

这是最通用也最推荐的方式,适用于任何列名:

列表 50.3
# ==================== 方法2:使用方括号选择单列 ====================
# df['收盘价']:使用方括号和列名字符串选择列
# 优势:适用于任何列名,包括包含空格、特殊字符或以数字开头的列名
prices = df['收盘价']  # 返回一个Series对象
print('\n方法2 - 方括号选择:')
print(prices)
# 输出解读:
# - 显示每只股票的收盘价
# - 方括号方式是最通用的列选择方法
# 应用场景:
#   - 列名包含空格(如'收盘 价')
#   - 列名以数字开头(如'2024年营收')
#   - 列名是变量(如col = '收盘价'; df[col])

方括号方式的优势在于它可以处理任何字符串作为列名,包括以数字开头、包含空格或特殊字符的列名。此外,当你需要以字符串变量形式传递列名时(如col = '收盘价'; df[col]),这种方式是唯一的选择。

方式3:属性访问链(df.column_name)

点号方式可以与属性链式调用结合,使代码更加简洁:

列表 50.4
# ==================== 方法3:链式操作 ====================
# df.收盘价.mean():先选择'收盘价'列,再计算其均值
# 链式调用:连续调用多个方法,代码更简洁
mean_price = df.收盘价.mean()  # .mean():计算均值
print(f'\n平均价格: {mean_price:.2f}元')
# 输出解读:显示4只股票的平均收盘价
# 金融含义:
#   - 平均价格是价格的集中趋势度量
#   - 可以作为价格的"典型值"参考

# 验证:使用方括号方式计算同样的均值
mean_price_alt = df['收盘价'].mean()  # 等价于上面的表达式
print(f'验证(应相同): {mean_price_alt:.2f}元')
# 输出解读:两种方式的结果完全一致
# 应用:推荐使用方括号方式,因为更通用且不易出错

50.4.2 多列数据的选择

当我们需要同时选择多列时,必须将列名放在一个列表中传递给DataFrame:

列表 50.5
# ==================== 选择多列 ====================
# df[['股票代码', '股票名称', '收盘价']]:使用列表的列表选择多列
# 注意:外层方括号是DataFrame的索引语法,内层方括号是Python列表
subset = df[['股票代码', '股票名称', '收盘价']]
print('多列选择结果:')
print(subset)
print(f'\n数据类型: {type(subset)}')
# 输出解读:
# - 显示3列的数据(股票代码、名称、收盘价)
# - 返回类型是DataFrame(不是Series)
# 金融含义:
#   - DataFrame表示二维数据结构
#   - 即使只选择2列,仍然保持表格形式
# 应用场景:
#   - 从包含数十个财务指标的宽表中提取几个关键指标
#   - 构建分析所需的最小数据集

关键观察:多列选择返回的仍然是DataFrame对象,而不是Series。这符合直觉:我们选择的是数据的子集,但仍然保持二维表格结构。

金融应用提示:在实际投资分析中,我们经常需要从包含数十个财务指标的宽表中提取几个关键指标。例如,从Compustat数据库中可能包含资产负债表、利润表、现金流量表的数百个字段,但我们的分析可能只需要其中10-20个。多列选择让这一操作变得非常简洁。

50.5 行数据的索引与切片

50.5.1 理解loc与iloc的本质区别

Pandas的两套索引系统经常让初学者困惑,但理解它们的设计哲学后,选择就变得清晰了。

loc(label-based indexing):关注“是什么” - 使用数据的实际标签(标签可以是字符串、日期、时间戳等) - 切片操作包含结束位置(闭区间 [start, end]) - 更符合人类的语义直觉

iloc(position-based indexing):关注“在哪里” - 使用整数位置,从0开始计数 - 切片操作不包含结束位置(半开区间 [start, end)) - 与Python列表、NumPy数组的索引方式一致,便于算法实现

50.5.2 使用loc进行标签索引

在金融数据分析中,loc是最常用的索引方式,因为我们的数据几乎总是带有意义的标签(股票代码、日期、公司名称等)。

列表 50.6
# ==================== 设置股票代码为索引 ====================
# df.set_index('股票代码'):将'股票代码'列设为行索引
# inplace=False(默认):返回新的DataFrame,不修改原数据
df_indexed = df.set_index('股票代码')

print('索引后的DataFrame:')
print(df_indexed)
print('\n行索引:', df_indexed.index.tolist())
# 输出解读:
# - '股票代码'列从数据列变成了行索引
# - 行索引是['600519.SH', '000858.SZ', '600036.SH', '601318.SH']
# 金融含义:
#   - 索引是数据的"身份证",用于快速定位和查找
#   - 股票代码作为索引,可以直接用代码查找数据

# ==================== 选择单行:返回Series ====================
# df_indexed.loc['600519.SH']:使用loc选择标签为'600519.SH'的行
# .loc[]:基于标签的索引器,使用数据的实际标签值
stock1 = df_indexed.loc['600519.SH']
print('\n选择单行 - 贵州茅台:')
print(stock1)
print(f'返回类型: {type(stock1)}')
# 输出解读:
# - 显示贵州茅台的所有列数据(名称、价格、涨跌幅、成交量)
# - 返回类型是Series
# - Series的索引是列名('股票名称', '收盘价', '涨跌幅', '成交量')
# 金融含义:
#   - 单行选择用于查看某只股票的完整信息

# ==================== 选择多行:返回DataFrame ====================
# df_indexed.loc[['600519.SH', '000858.SZ']]:选择多行,传入标签列表
stocks_subset = df_indexed.loc[['600519.SH', '000858.SZ']]
print('\n选择多行:')
print(stocks_subset)
print(f'返回类型: {type(stocks_subset)}')
# 输出解读:
# - 显示两只股票的数据(贵州茅台和五粮液)
# - 返回类型是DataFrame(保持二维结构)
# 金融含义:
#   - 多行选择用于对比分析多只股票

关键概念:当你选择单行时,Pandas返回Series;当你选择多行时,返回DataFrame。这种设计保持了类型的一致性:Series表示一维数据,DataFrame表示二维数据。

切片操作的闭区间特性:

列表 50.7
# ==================== 切片操作(重要:包含结束位置!)====================
# df_indexed.loc['600519.SH':'600036.SH']:使用标签切片
# 关键特性:loc的切片包含结束位置(闭区间)
stocks_slice = df_indexed.loc['600519.SH':'600036.SH']
print('\nloc切片操作(包含边界):')
print(stocks_slice)
# 输出解读:
# - 显示从'600519.SH'到'600036.SH'的所有行(包含两端)
# - 这是闭区间[start, end],与Python的半开区间[start, end)不同
# 金融含义:
#   - 符合人类直觉:说"从A到B"时,通常包含A和B
#   - 避免了"差一错误"(Off-by-one error)
# 应用场景:
#   - 选择特定日期范围的交易数据(包含起止日期)
#   - 选择特定股票代码区间的数据

注意这里的关键特性:loc的切片包含结束位置'600519.SH':'600036.SH'会包含这两只股票以及它们之间的所有行(如果有)。这与Python传统的切片行为不同,设计原因是:当你使用标签”600036.SH”时,你期望的是”包含600036.SH的数据”,而非”到600036.SH之前的数据”。

50.5.3 同时选择行和列

loc最强大的功能之一是可以同时进行行和列的选择,这让我们能够精确提取数据的任意子集:

列表 50.8
# ==================== 选择特定行的特定列 ====================
# df_indexed.loc[['600519.SH', '000858.SZ'], ['股票名称', '收盘价']]
# 第一个参数:行标签列表
# 第二个参数:列名列表
df_subset = df_indexed.loc[['600519.SH', '000858.SZ'], ['股票名称', '收盘价']]
print('\n行和列的组合选择:')
print(df_subset)
# 输出解读:
# - 显示两只股票的名称和收盘价
# - 这是数据的子集(2行×2列)
# 金融含义:
#   - 精确提取所需的行和列,减少数据量
#   - 类似SQL的SELECT列 FROM 表 WHERE 行条件

# ==================== 选择单个值(简化形式)====================
# df_indexed.loc['600519.SH', '收盘价']:选择特定行的特定列的值
# 返回标量值(单个数字),而不是Series
specific_value = df_indexed.loc['600519.SH', '收盘价']
print(f'\n单个标量值: {specific_value}')
print(f'单个值的类型: {type(specific_value)}')
# 输出解读:
# - 返回单个数字(如1850.0),不是Series
# - 这是提取单个值最高效的方式
# 金融含义:
#   - 获取某只股票在某天的具体价格
#   - 用于程序中访问特定值
# 性能提示:
#   - df.at[row_label, col_label]是专门为标量访问优化的方法,比loc更快

性能提示:对于大型DataFrame,如果只需要提取几个特定的值,使用df.at[row_label, col_label]比loc更快,因为at是专门为标量访问优化的方法。

50.5.4 使用iloc进行位置索引

iloc在以下场景中特别有用: - 编写与数据内容无关的通用算法 - 处理没有标签的纯数值数据 - 在循环中按顺序处理数据

列表 50.9
# ==================== 恢复默认整数索引 ====================
# df.reset_index(drop=True):重置索引,丢弃原索引
# drop=True:不将原索引保留为列
df_reset = df.reset_index(drop=True)

# ==================== 选择第0行(第一行)====================
# df_reset.iloc[0]:使用iloc选择第0行(整数位置0)
# iloc:基于位置的索引器,使用整数索引(从0开始)
row_0 = df_reset.iloc[0]
print('\niloc选择第0行:')
print(row_0)
# 输出解读:
# - 显示第1行的所有列数据(贵州茅台)
# - 位置0对应第1行(Python索引从0开始)
# 金融含义:
#   - 当数据没有有意义的标签时,用位置索引很方便
#   - 循环处理每一行时,iloc更自然

# ==================== 选择第1-2行(半开区间)====================
# df_reset.iloc[1:3]:使用切片选择多行
# 关键特性:iloc的切片不包含结束位置(半开区间[start, end))
rows_1_2 = df_reset.iloc[1:3]
print('\niloc切片1:3(不包含索引3):')
print(rows_1_2)
# 输出解读:
# - 显示第2行和第3行(索引1和2),不包含第4行(索引3)
# - 这是Python标准的半开区间
# 金融含义:
#   - 与Python列表、NumPy数组的切片行为一致
#   - 便于算法实现(如取前N行:iloc[:N])

# ==================== 选择特定行和列的位置 ====================
# df_reset.iloc[0, 2]:选择第0行第2列的值
# 第一个参数:行位置(0)
# 第二个参数:列位置(2,即'收盘价'列)
cell = df_reset.iloc[0, 2]
print(f'\n第0行第2列的值: {cell}')
# 输出解读:
# - 返回第1行第3列的值(1850.0)
# 金融含义:
#   - 直接用位置访问单元格,不需要知道行列标签

# ==================== 使用负索引(从末尾计数)====================
# df_reset.iloc[-1]:使用负索引选择最后一行
# -1表示倒数第1个,-2表示倒数第2个,以此类推
last_row = df_reset.iloc[-1]
print(f'\n最后一行:\n{last_row}')
# 输出解读:
# - 显示最后1行(中国平安的数据)
# 金融含义:
#   - 获取最新的数据(如最近一天的交易数据)
#   - 倒数第N的数据

ilist的切片行为遵循Python的半开区间惯例:iloc[1:3]只包含索引1和2,不包含索引3。这与loc的闭区间行为形成对比,体现了两套系统基于不同的设计理念。

何时使用iloc?

一个实际案例:假设你想对DataFrame的每一行应用某个复杂函数,但不关心行的具体标签,只需要遍历所有行:

列表 50.10
# ==================== 使用iloc遍历所有行 ====================
# for循环:遍历每一行
# range(len(df_reset)):生成0到行数-1的整数序列
print('\n使用iloc遍历每一行:')
for i in range(len(df_reset)):
    row = df_reset.iloc[i]  # 获取第i行的数据(Series)
    # 在实际应用中,这里可能进行复杂计算
    if i < 3:  # 只打印前3行作为示例
        print(f'第{i}行: {row["股票名称"]} 收盘价={row["收盘价"]}')
# 输出解读:
# - 显示前3只股票的名称和收盘价
# 金融含义:
#   - 逐行处理数据,如计算每只股票的某个指标
#   - 当需要按顺序处理所有行时,这种方式很直观
# 性能提示:
#   - 大规模数据处理时,尽量避免使用循环
#   - 优先使用向量化操作(如df.apply()或直接向量化计算)

50.6 条件筛选与布尔索引

50.6.1 布尔索引的数学原理

布尔索引(Boolean Indexing)是Pandas中最强大也最常用的数据筛选技术。其数学定义是:给定一个与DataFrame形状相同的布尔矩阵 \(B \in \{\text{True}, \text{False}\}^{n \times m}\),返回满足条件 \(B_{ij} = \text{True}\) 的元素。

在实践中,我们通常逐列应用布尔条件,然后选择满足条件的行:

\[ \text{Selected Rows} = \{i \in \{1, \ldots, n\} | P(\text{row}_i) = \text{True}\} \]

其中 \(P\) 是定义在行数据上的谓词函数。

50.6.2 单条件筛选

列表 50.11
# ==================== 构建布尔条件 ====================
# df['收盘价'] > 100:比较操作,返回布尔Series
# 每一行对应一个True/False值
condition = df['收盘价'] > 100
print('布尔条件(收盘价>100):')
print(condition)
print(f'\n布尔条件的类型: {type(condition)}')
# 输出解读:
# - 显示每只股票的收盘价是否大于100
# - True表示满足条件(价格>100),False表示不满足
# - 返回类型是Series(布尔类型)
# 金融含义:
#   - 这是筛选的"第一判断",还没有真正筛选数据

# ==================== 应用布尔条件筛选 ====================
# df[condition]:使用布尔Series索引DataFrame
# 只保留condition为True的行,丢弃condition为False的行
high_price = df[condition]
print('\n筛选结果(收盘价>100的股票):')
print(high_price)
# 输出解读:
# - 只显示收盘价大于100的股票(贵州茅台1850元,五粮液220.5元)
# - 招商银行(45.2元)和中国平安(52.8元)被过滤掉
# 金融含义:
#   - 筛选出高价股(通常是大盘蓝筹股)
#   - 可以用于构建投资组合(如只投资价格>100的股票)
# 应用场景:
#   - 筛选符合条件的股票(如PE<20, ROE>15%)
#   - 过滤异常值(如剔除价格<5的股票,避免ST股)

关键理解:df['收盘价'] > 100这个表达式返回一个与原DataFrame行数相同的Series,其值为True或False。当我们将这个Series传给df[]时,Pandas会保留所有对应True的行,丢弃对应False的行。

这种操作方式非常符合数学定义,也非常直观:我们在描述”筛选价格大于100的股票”时,思维过程就是”检查每一行是否满足条件,如果满足就保留”。

50.6.3 多条件筛选

在实际分析中,我们经常需要组合多个条件。Pandas使用Python的位运算符来实现逻辑组合:

  • & (AND): 同时满足两个条件
  • | (OR): 满足至少一个条件
  • ~ (NOT): 条件取反

重要提示:必须使用括号将每个条件括起来,因为位运算符的优先级高于比较运算符!

列表 50.12
# ==================== 多条件AND(逻辑与)====================
# (df['收盘价'] > 50) & (df['涨跌幅'] > 0):两个条件同时满足
# &:位运算符AND,要求两个条件都为True
# 注意:必须用括号包围每个条件!
condition_and = (df['收盘价'] > 50) & (df['涨跌幅'] > 0)
print('\nAND条件筛选(价格>50 且 涨幅>0):')
result_and = df[condition_and]  # 筛选满足AND条件的行
print(result_and[['股票名称', '收盘价', '涨跌幅']])
# 输出解读:
# - 显示同时满足两个条件的股票(价格>50 且 涨幅>0)
# - 贵州茅台:价格1850>50 ✓,涨幅0.05>0 ✓ → 保留
# - 五粮液:价格220.5>50 ✓,涨幅-0.02>0 ✗ → 过滤
# - 招商银行:价格45.2>50 ✗ → 过滤
# - 中国平安:价格52.8>50 ✓,涨幅-0.01>0 ✗ → 过滤
# 金融含义:
#   - AND筛选用于"交集",筛选同时满足多个标准的股票
#   - 如:高收益 AND 低风险

# ==================== 多条件OR(逻辑或)====================
# (df['股票名称'].str.contains('银行')) | (df['股票名称'].str.contains('保险'))
# |:位运算符OR,满足至少一个条件即可
# .str.contains():字符串方法,检查是否包含子串
condition_or = (df['股票名称'].str.contains('银行')) | (df['股票名称'].str.contains('保险'))
print('\n\nOR条件筛选(银行或保险):')
result_or = df[condition_or]  # 筛选满足OR条件的行
print(result_or[['股票名称', '收盘价']])
# 输出解读:
# - 显示名称包含"银行"或"保险"的股票
# - 贵州茅台:不含"银行"或"保险" ✗ → 过滤
# - 五粮液:不含"银行"或"保险" ✗ → 过滤
# - 招商银行:包含"银行" ✓ → 保留
# - 中国平安:包含"保险" ✓ → 保留
# 金融含义:
#   - OR筛选用于"并集",筛选满足任一条件的股票
#   - 如:银行股 或 保险股(金融板块)
# 应用场景:
#   - 行业筛选:选择特定行业的公司
#   - 多因子筛选:满足任意一个因子即可

字符串方法:这里使用了.str.contains()方法,这是Pandas提供的向量化字符串操作,对整列数据进行模式匹配。在实际应用中,这比使用循环和Python的in操作符要高效得多,也更简洁。

50.6.4 复杂条件与query方法

当条件变得复杂时,代码的可读性会下降。Pandas提供了query()方法,让我们用类似SQL的语法来表达筛选条件:

列表 50.13
# ==================== 传统方法:布尔索引 ====================
# 构建复杂的AND条件:价格合理(40-2000)、上涨、且成交量活跃
# &:多个AND条件串联
complex_condition = (
    (df['收盘价'] > 40) &  # 价格下限:>40元
    (df['收盘价'] < 2000) &  # 价格上限:<2000元
    (df['涨跌幅'] > 0) &  # 上涨:涨幅>0
    (df['成交量'] > 2000)  # 成交活跃:成交量>2000手
)

result_complex = df[complex_condition]
print('\n复杂条件筛选结果:')
print(result_complex[['股票名称', '收盘价', '涨跌幅', '成交量']])
# 输出解读:
# - 显示同时满足4个条件的股票
# 金融含义:
#   - 价格在合理区间(40-2000元),避免低价股和过度高价股
#   - 当日上涨(涨幅>0),剔除下跌股票
#   - 成交活跃(成交量>2000手),保证流动性
# 应用:这是多因子选股的典型条件组合

# ==================== 使用query方法(更简洁的语法)====================
# df.query('收盘价 > 40 and 收盘价 < 2000 and 涨跌幅 > 0 and 成交量 > 2000')
# query():使用字符串表达式筛选数据,语法类似SQL
# 优势:不需要重复输入df['列名'],代码更简洁
result_query = df.query('收盘价 > 40 and 收盘价 < 2000 and 涨跌幅 > 0 and 成交量 > 2000')
print('\n使用query方法的等价结果:')
print(result_query[['股票名称', '收盘价', '涨跌幅', '成交量']])
# 输出解读:
# - 两种方法的结果完全一致
# - query方法的表达式更接近自然语言
# query方法的优势:
#   1. 可读性:表达式更接近自然语言
#   2. 简洁:不需要重复输入df['列名']
#   3. 支持外部变量:使用@var_name引用外部变量
# 何时使用query:
#   - 当筛选条件包含3个以上的AND/OR组合时
#   - 需要动态构建筛选条件时
#   - 提高代码可读性时
# 注意:简单的1-2个条件,传统布尔索引更直接高效

query方法的优势: 1. 可读性:表达式更接近自然语言 2. 简洁:不需要重复输入df['列名'] 3. 支持外部变量:使用@var_name引用外部变量

何时使用query?当你的筛选条件包含3个以上的AND/OR组合时,query方法通常能让代码更易读。但对于简单的1-2个条件,传统的布尔索引方式更加直接和高效。

50.6.5 isin方法成员资格检查

成员检查(Membership Test)是数据筛选中的常见需求。例如,从一个包含数千只股票的池中筛选出我们关注的几十只股票。

列表 50.14
# ==================== 场景:从所有股票中筛选出关注的股票 ====================
# watchlist:关注的股票列表(白名单)
# 场景:从股票池中筛选出我们跟踪的股票
watchlist = ['600519.SH', '600036.SH']  # 关注贵州茅台和招商银行
# df['股票代码'].isin(watchlist):检查股票代码是否在关注列表中
# isin():向量化成员检查,返回布尔Series
selected = df[df['股票代码'].isin(watchlist)]

print('筛选关注列表中的股票:')
print(selected[['股票代码', '股票名称', '收盘价']])
# 输出解读:
# - 显示关注列表中的股票(贵州茅台和招商银行)
# - 五粮液和中国平安不在关注列表中,被过滤
# 金融含义:
#   - 这是"白名单"筛选,只保留关注的股票
#   - 常用于跟踪特定股票池(如自选股、成分股)
# 应用场景:
#   - 从全市场股票中筛选沪深300成分股
#   - 从所有基金中筛选出我们跟踪的几只基金

# ==================== 组合使用:筛选关注列表中上涨的股票 ====================
# df['股票代码'].isin(watchlist) & (df['涨跌幅'] > 0)
# 组合条件:在关注列表中 且 当日上涨
# &:逻辑AND,两个条件同时满足
selected_and_rising = df[
    df['股票代码'].isin(watchlist) &
    (df['涨跌幅'] > 0)
]
print('\n关注列表中上涨的股票:')
print(selected_and_rising[['股票名称', '涨跌幅']])
# 输出解读:
# - 显示关注列表中当日上涨的股票
# - 贵州茅台在关注列表中,涨幅0.05>0 ✓ → 保留
# - 招商银行在关注列表中,但涨幅0.03>0 ✓ → 保留
# 金融含义:
#   - 筛选"自选股中今日上涨"的股票
#   - 用于监控关注股票的表现
# 性能考虑:
#   - isin()使用哈希表算法,时间复杂度O(1)
#   - 比链式| (OR)操作高效得多
#   - 大规模数据筛选时,优先使用isin()

性能考虑:对于大型DataFrame,isin()方法比使用链式|(OR)操作要高效得多,因为Pandas内部使用了哈希表算法来实现成员检查,时间复杂度是O(1)而非O(n)。

50.6.6 反向选择使用~运算符

有时我们需要选择不满足某个条件的数据。使用~运算符可以方便地实现反向选择:

列表 50.15
# ==================== 反向选择:排除特定股票 ====================
# ~df['股票代码'].isin(['601318.SH', '000001.SZ'])
# ~:位运算符NOT,对布尔条件取反
# True变成False,False变成True
excluded = df[~df['股票代码'].isin(['601318.SH', '000001.SZ'])]
print('\n排除特定股票后的结果:')
print(excluded[['股票代码', '股票名称']])
# 输出解读:
# - 显示除了中国平安(601318.SH)和平安银行(000001.SZ)之外的股票
# - 中国平安在排除列表中 → 过滤
# - 贵州茅台、五粮液、招商银行不在排除列表中 → 保留
# 金融含义:
#   - 这是"黑名单"筛选,剔除不想要的股票
#   - 常用于剔除ST股票、风险股票、竞争对手的股票
# 应用场景:
#   - 从指数成分股中剔除ST股票(特别处理股票)
#   - 从股票池中排除已经持仓的股票
#   - 剔除某个行业的股票(如排除房地产)
# 组合技巧:
#   - ~ (NOT) 可以与其他条件组合
#   - 如:~(df['市盈率'] > 100):剔除市盈率>100的股票

金融应用:这种操作在构建投资组合时很常见。例如,我们有所有A股的数据,但需要从指数成分股中剔除ST股票(Special Treatment,即特别处理股票,通常是因为财务异常),就可以使用反向选择。

50.7 索引的高级操作

50.7.1 设置与重置索引

索引的设置(set_index)和重置(reset_index)是数据预处理中的常见操作。

set_index的应用场景: - 将股票代码设为索引,便于按代码查找 - 将日期设为索引,便于时间序列分析 - 创建多级索引(MultiIndex),处理面板数据

reset_index的应用场景: - 将索引恢复为普通列 - 在筛选或分组操作后重置行号 - 在合并操作前规范化索引结构

列表 50.16
# ==================== 设置索引 ====================
# df.set_index('股票代码'):将'股票代码'列设为行索引
# inplace=False(默认):返回新的DataFrame,不修改原数据
df_with_index = df.set_index('股票代码')
print('\n设置股票代码为索引:')
print(df_with_index.head())
# 输出解读:
# - '股票代码'列从数据列变成行索引
# - 索引不再是0,1,2,3,而是股票代码
# 金融含义:
#   - 可以用股票代码直接查找数据
#   - 如:df_with_index.loc['600519.SH']查找贵州茅台

# ==================== 重置索引 ====================
# df_with_index.reset_index():将索引恢复为普通列
# 默认行为:将索引变成名为'index'的列(如果原索引有名称,使用原名称)
df_reset = df_with_index.reset_index()
print('\n重置索引后:')
print(df_reset.head())
# 输出解读:
# - 股票代码索引变回普通列(列名为'股票代码')
# - 行索引恢复为0,1,2,3(整数序列)
# 金融含义:
#   - 将索引数据还原为可分析的列
#   - 重置行号便于后续操作(如合并、导出)

# ==================== 丢弃原索引的重置 ====================
# df_with_index.reset_index(drop=True)
# drop=True:不保留原索引,直接删除
# 原索引(股票代码)会被丢弃,不会变成列
df_reset_drop = df_with_index.reset_index(drop=True)
print('\n丢弃原索引的重置(股票代码列被删除):')
print(df_reset_drop.head())
# 输出解读:
# - 股票代码索引被永久删除
# - 只剩下其他列(股票名称、收盘价、涨跌幅、成交量)
# 应用场景:
#   - 当索引信息不需要保留时
#   - 简化数据结构
# 多级索引(MultiIndex):
#   - Pandas的高级特性,适合处理面板数据(Panel Data)
#   - 如:(股票代码, 日期)两级索引
#   - 可以同时进行"按股票"和"按时间"的分析

多级索引(MultiIndex)是Pandas的高级特性,特别适合处理面板数据(Panel Data),即”个体-时间”的双维度数据。例如,对于一个包含多只股票多年数据的DataFrame,可以设置(股票代码,日期)的两级索引,这样就能方便地同时进行”按股票”和”按时间”的分析。

50.7.2 交换索引与堆叠

对于多维数据,Pandas还提供了stack()unstack()方法来转换数据的组织方式:

  • stack():将列索引转换为行索引(数据变”高”和”窄”)
  • unstack():将行索引转换为列索引(数据变”矮”和”宽”)

这些操作在处理面板数据时非常有用,例如将”年份-指标”的宽表转换为”年份-指标-值”的长表,以便进行时间序列分析。

50.8 数据检索的性能优化

对于大型数据集(数百万行或更大),数据检索的性能就变得重要。以下是几个关键的优化策略:

50.8.1 1. 使用.loc而非链式索引

低效的链式索引:

# 不推荐:链式索引
result = df[df['收盘价'] > 100]['股票名称']

推荐的.loc方法:

# 推荐:单次loc操作
result = df.loc[df['收盘价'] > 100, '股票名称']

链式索引会返回数据的副本(copy)而非视图(view),可能导致: 1. 性能下降:不必要的内存复制 2. SettingWithCopyWarning:当尝试修改结果时,Py的警告

50.8.2 2. 善用索引加速查找

如果需要频繁查找特定标签的数据,将其设为索引可以显著加速:

列表 50.17
# ==================== 导入必要的库 ====================
import time  # 时间库,用于计时

# ==================== 创建较大的DataFrame ====================
# 场景:10万行的股票交易数据
n_rows = 100000  # 行数
large_df = pd.DataFrame({
    'code': np.random.choice([f'{i:06d}.SH' for i in range(1, 1001)], n_rows),  # 随机股票代码
    'price': np.random.uniform(10, 100, n_rows),  # 随机价格(10-100元)
    'volume': np.random.randint(1000, 10000, n_rows)  # 随机成交量(1000-10000手)
})

# ==================== 方法1:不使用索引,使用布尔索引 ====================
# large_df[large_df['code'] == '000500.SH']:布尔索引
# 需要扫描所有行的'code'列,时间复杂度O(n)
start = time.time()  # 开始计时
result1 = large_df[large_df['code'] == '000500.SH']
time1 = time.time() - start  # 计算耗时

# ==================== 方法2:使用索引 ====================
# large_df.set_index('code'):将'code'列设为索引
# indexed_df.loc['000500.SH']:使用索引查找
# 索引使用哈希表或B-Tree,时间复杂度O(1)或O(log n)
indexed_df = large_df.set_index('code')
start = time.time()
result2 = indexed_df.loc['000500.SH']
time2 = time.time() - start

print(f'\n布尔索引耗时: {time1*1000:.2f}毫秒')
print(f'索引查找耗时: {time2*1000:.2f}毫秒')
print(f'性能提升: {time1/time2:.1f}倍')
# 输出解读:
# - 布尔索引需要扫描所有行,速度较慢
# - 索引查找直接定位,速度快几十倍
# 金融含义:
#   - 对于频繁查找的数据,设为索引可以大幅提升性能
#   - 如:按股票代码、日期查找
# 性能原理:
#   - 索引使用哈希表(Hash Table)或B-Tree数据结构
#   - 查找时间复杂度从O(n)降低到O(1)或O(log n)
# 应用场景:
#   - 需要频繁按某列查找时(如股票代码、日期)
#   - 数据量大(>10万行)时
#   - 查找操作多于更新操作时

性能原理:当使用索引时,Pandas内部使用哈希表(Hash Table)或B-Tree数据结构来组织数据,使得查找操作的时间复杂度从O(n)降低到O(1)或O(log n)。

50.8.3 3. 向量化操作替代循环

列表 50.18
# ==================== 不推荐:使用循环 ====================
# for循环逐行处理,效率低
# 每次循环都要调用iloc,有大量Python解释器开销
start = time.time()
prices_loop = []
for i in range(len(df)):
    if df.iloc[i]['收盘价'] > 100:  # 逐行判断
        prices_loop.append(df.iloc[i]['收盘价'])  # 逐行提取
time_loop = time.time() - start

# ==================== 推荐:向量化操作 ====================
# df.loc[df['收盘价'] > 100, '收盘价']:一次性筛选所有符合条件的行
# 向量化操作使用C/Fortran实现,避免Python循环开销
start = time.time()
prices_vectorized = df.loc[df['收盘价'] > 100, '收盘价']
time_vectorized = time.time() - start

print(f'\n循环方法耗时: {time_loop*1000:.2f}毫秒')
print(f'向量化方法耗时: {time_vectorized*1000:.2f}毫秒')
print(f'性能提升: {time_loop/time_vectorized:.1f}倍')
# 输出解读:
# - 向量化方法比循环快几十倍到上百倍
# 金融含义:
#   - 大规模数据处理时,必须使用向量化操作
#   - 循环只用于无法向量化的场景(如复杂逻辑)
# 为什么向量化更快?
#   1. 底层优化:Pandas/NumPy用C/Fortran实现,避免Python解释器开销
#   2. SIMD指令:现代CPU的单指令多数据并行计算能力
#   3. 内存局部性:连续内存访问,更好利用CPU缓存
# 应用:
#   - 永远优先使用向量化操作
#   - 避免在Pandas中使用for循环
#   - 使用df.apply()替代循环

为什么向量化更快? 1. 底层优化:Pandas和NumPy的操作是用C和Fortran实现的,避免了Python解释器的开销 2. SIMD指令:现代CPU的单指令多数据(SIMD)并行计算能力 3. 内存局部性:连续的内存访问模式更好的利用CPU缓存

50.9 金融应用案例价值投资策略筛选

让我们通过一个完整的金融应用案例,将本章所学知识融会贯通。

案例背景:假设我们是一名基金经理,需要从A股市场中筛选出符合”价值投资”标准的股票。价值投资的核心思想是”用50美分买价值1美元的股票”,即寻找被市场低估的优质公司。

筛选标准(基于巴菲特和格雷厄姆的价值投资理念): 1. 估值合理:市盈率(PE) < 20,市净率(PB) < 3 2. 盈利能力强:净资产收益率(ROE) > 15% 3. 财务稳健:负债率 < 60% 4. 持续分红:股息率 > 2%

列表 50.19
# =============================================================================
# 题目:综合应用:价值投资策略筛选
# =============================================================================
# 本任务演示如何综合运用数据检索技术进行价值投资选股
# 场景:从1000只股票中筛选出符合价值投资标准的优质公司

# ==================== 导入必要的库 ====================
import pandas as pd  # Pandas数据分析库
import numpy as np  # NumPy数值计算库

# ==================== 创建模拟A股市场数据 ====================
# 场景:模拟1000只A股的财务数据
np.random.seed(42)  # 设置随机种子,确保结果可复现
n_stocks = 1000  # 股票数量

# 生成随机财务数据
stocks_data = pd.DataFrame({
    # 股票代码:格式如'000001.SH', '000002.SZ'等
    '股票代码': [f'{i:06d}.{"SH" if i%2==0 else "SZ"}' for i in range(1, n_stocks+1)],
    # 股票名称:简单命名为'股票1', '股票2'等
    '股票名称': [f'股票{i}' for i in range(1, n_stocks+1)],
    # 市盈率(PE):5-80之间的均匀分布
    # PE < 20:估值合理;PE > 80:估值过高
    '市盈率': np.random.uniform(5, 80, n_stocks),
    # 市净率(PB):0.5-10之间的均匀分布
    # PB < 3:股价不超过净资产的3倍
    '市净率': np.random.uniform(0.5, 10, n_stocks),
    # ROE(净资产收益率):5%-35%之间的均匀分布
    # ROE > 15%:盈利能力强
    'ROE': np.random.uniform(0.05, 0.35, n_stocks),
    # 负债率:20%-90%之间的均匀分布
    # 负债率 < 60%:财务稳健
    '负债率': np.random.uniform(0.2, 0.9, n_stocks),
    # 股息率:0.5%-8%之间的均匀分布
    # 股息率 > 2%:分红慷慨
    '股息率': np.random.uniform(0.005, 0.08, n_stocks)
})

print(f'原始股票池: {len(stocks_data)}只股票')
print(f'\n原始数据概况:')
print(stocks_data[['市盈率', '市净率', 'ROE', '负债率', '股息率']].describe())
# 输出解读:
# - 显示1000只股票的5个财务指标的统计摘要
# - count:样本数(1000)
# - mean:均值
# - std:标准差
# - min/max:最小值/最大值
# - 25%/50%/75%:分位数
# 金融含义:
#   - 快速了解股票池的整体情况
#   - 识别是否存在异常值或分布偏斜

# ==================== 应用价值投资筛选条件 ====================
# 组合多个AND条件:估值合理、盈利强、财务稳健、分红好
# &:逻辑AND,所有条件必须同时满足
value_criteria = (
    (stocks_data['市盈率'] < 20) &           # PE合理:<20
    (stocks_data['市净率'] < 3) &            # PB合理:<3
    (stocks_data['ROE'] > 0.15) &            # ROE高:>15%
    (stocks_data['负债率'] < 0.6) &           # 负债率合理:<60%
    (stocks_data['股息率'] > 0.02)            # 分红慷慨:>2%
)

# 筛选满足所有条件的股票
value_stocks = stocks_data[value_criteria].copy()  # .copy():创建副本,避免视图问题

print(f'\n筛选后符合价值投资标准的股票: {len(value_stocks)}只')
print(f'通过率: {len(value_stocks)/len(stocks_data)*100:.1f}%')
# 输出解读:
# - 显示通过筛选的股票数量和比例
# - 通过率约5%,说明价值投资标准很严格
# 金融含义:
#   - 真正的"价值股"在市场上并不多见
#   - 严格筛选保证了投资组合的质量
# 应用:
#   - 价值投资策略的核心是"宁缺毋滥"
#   - 5%的通过率意味着仍有足够的股票构建组合(30-50只)

# ==================== 展示筛选结果(按ROE排序)====================
# value_stocks.sort_values('ROE', ascending=False):按ROE降序排序
# ascending=False:降序(从大到小)
value_stocks_sorted = value_stocks.sort_values('ROE', ascending=False)
print(f'\n价值投资组合(前10只,按ROE排序):')
print(value_stocks_sorted[['股票代码', '市盈率', '市净率', 'ROE', '负债率', '股息率']].head(10).round(2))
# 输出解读:
# - 显示ROE最高的前10只价值股
# - round(2):保留2位小数
# 金融含义:
#   - 优先选择盈利能力最强的公司
#   - ROE是巴菲特判断优秀公司的核心指标

# ==================== 计算组合的统计特征 ====================
# 对筛选出的股票计算各指标的均值
print(f'\n价值组合统计特征:')
print(f'平均市盈率: {value_stocks["市盈率"].mean():.2f}')
print(f'平均市净率: {value_stocks["市净率"].mean():.2f}')
print(f'平均ROE: {value_stocks["ROE"].mean():.2%}')
print(f'平均股息率: {value_stocks["股息率"].mean():.2%}')
# 输出解读:
# - 显示价值投资组合的平均特征
# 金融含义:
#   - 这些数字应该明显优于市场平均水平
#   - 如:平均ROE 20% vs 市场12%,说明筛选出的公司盈利能力强
# 应用:
#   - 评估投资组合的整体质量
#   - 与市场基准或行业平均进行对比

结果分析: 1. 筛选严格性:从1000只股票中筛选出约50只,通过率约5%,说明价值投资标准相当严格,这也解释了为什么市场上真正的”价值股”并不多见 2. 质量特征:筛选出的股票普遍具有低估值、高盈利、低负债、高分红的特征,符合价值投资的核心理念 3. 分散化:5%的通过率意味着我们仍有足够的股票来构建一个充分分散的投资组合(通常30-50只股票已足够)

深入思考:为什么这些筛选标准是合理的?

  • 市盈率(PE):衡量”多少年能回本”。PE=20意味着按照当前盈利水平,需要20年收回投资成本。PE<20确保我们不会为成长性支付过高价格。

  • 市净率(PB):衡量”股价相对净资产的倍数”。PB<3意味着股价不超过每股净资产的3倍,提供了安全边际。即使公司清算,投资者也能获得一定的保护。

  • ROE:衡量”股东投入资本的回报率”。ROE>15%是巴菲特判断优秀公司的重要标准,意味着公司具有较强的竞争优势和盈利能力。

  • 负债率:衡量财务风险。负债率<60%确保公司不会因为债务负担过重而在经济下行时破产。

  • 股息率:提供”现金回报”。股息率>2%意味着即使股价不变,投资者每年也能获得超过银行存款的现金回报,这对长期投资者很重要。

策略局限性: 1. 价值陷阱:某些股票之所以估值低,可能是因为市场已经预期其未来业绩会下滑。这些”便宜”的股票可能实际上是”价值陷阱”。 2. 行业差异:不同行业的合理估值水平不同。银行股PE通常<10,而成长股可能PE>30但仍然合理。 3. 时间维度:价值投资需要耐心,被低估的股票可能长期被低估,投资者需要等待数年才能获得估值修复的收益。